<!-- ## week3 -->

import img0407 from "./img/0407.png";

## 架构设计图

<img src={img0407} />

## 脚手架拆包策略

- 核心流程：
  - core 模块
- 命令：commands
  - 初始化
  - 发布
  - 清除缓存
- 模型层 models
  - Command 命令
  - Project 项目
  - Component 组件
  - Npm 模块
  - Git 仓库
- 支撑模块 utils
  - Git 操作
  - 云构建
  - 工具方法
  - API 请求

### 拆分原则

根据模块的功能拆分  
核心模块：core  
命令模块：commands  
模型模块：models  
工具模块：utils

## core 模块技术方案

### 命令执行流程

- 准备阶段
- 命令注册
- 命令执行

#### 准备阶段分为以下步骤

- 检测脚手架版本号
- 检测 node 版本号
- 检查 root 账户和降级权限
- 检查用户主目录
- 入参检查及 debug 模式开发
- 检查环境变量
- 检查新版本及提示更新

### 准备阶段代码实现

- 检测脚手架版本号

```js
const log = require("@wdh-cli/util");
const pkg = require("../package.json");

function core() {
  checkVersion();
}

function checkVersion() {
  log.notice("cli", pkg.version);
}

module.exports = core;
```

- 检测 node 版本号
  node 版本号可以通过 process.version 获取

```js
const semver = require('semver');
const colors = require('colors/safe');
const constant = require('./const');

function core() {
    try {
        ...
        checkNodeVersion();
    } catch (error) {
        log.error(error.message)
    }
}

function checkNodeVersion() {
    const currentVersion = process.version;
      const lowestVersion = constant.LOWEST_NODE_VERSION;
    if (!semver.gte(currentVersion, lowestVersion)) {
        throw new Error(colors.red(`当前 node 版本：${currentVersion}，最低 node 版本：${lowestVersion}，请升级 node`))
    }
}
```

- 使用 root-check 来自动完成账号降级  
  当使用 root 账号创建的文件，别的账号是无法删除修改的，日常开发特别注意这一点。

  501 -> 普通账号（macOS）  
   0 -> root 账号：0

```js
function checkRoot() {
  const rootCheck = require("root-check");
  rootCheck();
  console.log(process.geteuid()); // 使用 process.geteuid() 来查看账号权限 501
}
// 经过 root-check 处理后，在使用 sudo 执行，结果就是 501
// 原理：通过 process.setuid 、 process.setgid 来动态修改了用户及其分组的权限
```

- 检查用户主目录

```js
const fs = require("fs");

const userHome = require("user-home");

function checkUserHome() {
  if (!userHome || !fs.existsSync(userHome)) {
    throw new Error(colors.red("当前登录用户主目录不存在！"));
  }
}
```

- 入参检查及 debug 模式开发

```js
function core() {
    try {
        ...
        checkInputArgs();
        log.verbose('debug', 'test debug modal');
    } catch (error) {
        log.error(error.message)
    }
}

/**
 * @description 检查入参
 */
function checkInputArgs() {
    const args = require('minimist')(process.argv.slice(2));
    checkArgs(args)
}

/**
 * @description 更加入参动态修改 log level，开启 debug 模式
 * @param {Object} args
 */
function checkArgs(args) {
    log.level = args.debug ? 'verbose' : 'info';
}
```

- 检查环境变量

可以在环境变量中存储用户信息，配置信息等数据。

- 通过 dotenv 这个库实现，dotenv 默认获取的是当前项目根目录中的 .env 文件。可以通过 path 属性来修改。
- env 文件中存储的数据格式是 key=value 这种格式的，获取到 .env 文件后会将里面的数据挂载到 process.env 上。

```js
function checkEnv() {
  const dotenvPath = path.resolve(userHome, ".env");
  // 读取 .env 文件中的数据，并配置到环境变量中
  require("dotenv").config({ path: dotenvPath });
  createDefaultConfig();
  log.verbose("环境变量缓存文件路径", process.env.CLI_HOME_PATH);
}

/**
 * @description 创建环境变量缓存文件路径
 */
function createDefaultConfig() {
  const cliConfig = {};
  cliConfig.cliHomePath = process.env.CLI_HOME
    ? path.join(userHome, process.env.CLI_HOME)
    : path.join(userHome, constant.DEFAULT_CLI_HOME);
  process.env.CLI_HOME_PATH = cliConfig.cliHomePath;
}
```

- 检查新版本

1. 获取 npm 上所有的版本号
2. 比较当前版本和 npm 上最新的版本
3. 存在新版本提示更新

```js
// core/index.js
async function checkGlobalUpdate() {
  log.verbose("检查 imooc-cli 最新版本");
  const currentVersion = pkg.version;
  const lastVersion = await npm.getNpmLatestSemverVersion(
    constant.NPM_NAME,
    currentVersion
  );

  console.log("d", lastVersion);
  if (lastVersion && semver.gt(lastVersion, currentVersion)) {
    log.warn(
      colors.yellow(`请手动更新 ${constant.NPM_NAME}，当前版本：${pkg.version}，最新版本：${lastVersion}
                更新命令： npm install -g ${constant.NPM_NAME}`)
    );
  }
}

// utils/log.js
const axios = require("axios");
const urlJoin = require("url-join");
const semver = require("semver");

// 获取 registry 信息
function getNpmRegistry(isOriginal = false) {
  return isOriginal
    ? "https://registry.npmjs.org"
    : "https://registry.npm.taobao.org";
}

// 从 registry 获取 npm 的信息
function getNpmInfo(npm, registry) {
  const register = registry || getNpmRegistry();
  const url = urlJoin(register, npm);
  return axios.get(url).then(function (response) {
    try {
      if (response.status === 200) {
        console.log("getNpmInfo", response.data);
        return response.data;
      }
    } catch (error) {
      return Promise.reject(error);
    }
  });
}

// 获取某个 npm 的最新版本号
function getLatestVersion(npm, registry) {
  return getNpmInfo(npm, registry).then(function (data) {
    if (!data["dist-tags"] || !data["dist-tags"].latest) {
      console.error("没有 latest 版本号", data);
      return Promise.reject(new Error("Error: 没有 latest 版本号"));
    }
    const latestVersion = data["dist-tags"].latest;
    return latestVersion;
  });
}

// 获取某个 npm 的所有版本号
function getVersions(npm, registry) {
  console.log("b3", registry);
  return new Promise((r, j) => {
    console.log("b4");

    getNpmInfo(npm, registry)
      .then(function (body) {
        const versions = Object.keys(body.versions);
        console.log("b5", versions);

        r(versions);
      })
      .catch(() => {
        j("11");
      });
  });
}

// 根据指定 version 获取符合 semver 规范的最新版本号
function getLatestSemverVersion(baseVersion, versions) {
  versions = versions
    .filter(function (version) {
      return semver.satisfies(version, "^" + baseVersion);
    })
    .sort(function (a, b) {
      return semver.gt(b, a);
    });
  return versions[0];
}

// // 根据指定 version 和包名获取符合 semver 规范的最新版本号
// function getNpmLatestSemverVersion(npm, baseVersion, registry) {
//   return getVersions(npm, registry).then((versions) => getLatestSemverVersion(baseVersion, versions));
// }

// 根据指定 version 和包名获取符合 semver 规范的最新版本号
async function getNpmLatestSemverVersion(npm, baseVersion, registry) {
  console.log("b2", npm);
  let versions;
  try {
    versions = await getVersions(npm, registry);
  } catch (err) {
    versions = ["111"];
  }
  console.log("c", baseVersion, versions);
  return getLatestSemverVersion(baseVersion, versions);
}

module.exports = {
  getNpmRegistry,
  getNpmInfo,
  getLatestVersion,
  getNpmLatestSemverVersion,
};
```

## commander 框架实现简单脚手架

```js
#!/usr/bin/env node

const commander = require("commander");
const program = new commander.Command();

const pkg = require("../package.json");

// 配置全局属性
program
  // 在 Commander 7 以前，选项的值是作为属性存储在 command 对象上的。
  // 这种处理方式便于实现，但缺点在于，选项可能会与Command的已有属性相冲突。
  // 通过使用.storeOptionsAsProperties()，可以恢复到这种旧的处理方式，并可以不加改动的继续运行遗留代码。
  // Commander 7之后所有的属性都要通过 opts() 获取
  .storeOptionsAsProperties()
  // .name 和 .usage 用来修改帮助信息的首行提示。name 属性也可以从参数自动推导出来。
  .name(Object.keys(pkg.bin)[0])
  .usage("<command> [option]")
  .version(pkg.version)
  // 配置 option 并设置默认值
  .option("-d, --debug", "开启调试模式", false)
  // 配置 option，指定参数
  .option("-e, --envName <envName>", "输出环境变量名称");

// command 注册命令，注意：调用 command 方法返回的时命令对象而不是脚手架对象
const clone = program.command("clone <source> [destination]");
clone
  .description("clone a repository")
  .option("-f, --force", "是否强制克隆")
  .action((source, destination, cmdObj) => {
    console.log("clone command called", source, destination, cmdObj.force);
  });

// addCommand 注册子命令
const service = new commander.Command("service");

service
  .command("start [port]")
  .description("service start at same port")
  .action((port) => {
    console.log("service starting at ", port);
  });

service
  .command("stop")
  .description("stop service")
  .action(() => {
    console.log("service stop");
  });

program.addCommand(service);

/**
 * @description 独立执行的命令
 * 当.command()带有描述参数时，就意味着使用独立的可执行文件作为子命令。
 * Commander 将会执行一个新的命令。新的命令为： 脚手架命令-注册的命令，如下例中未配置 executableFile 前执行的是 'zjw-cli-test-install'。
 * 通过配置选项 executableFile 可以自定义名字。
 * 这种方式可以实现脚手架之间的嵌套调用
 */
program
  .command("install [name]", "install package", {
    executableFile: "zjw-cli", // 由原先的 zjw-cli-test-install 改为执行 zjw-cli
    // isDefault: true, // 默认执行该命令，即执行 zjw-cli-test 就是执行 zjw-cli-test install
    hidden: true, // 在帮助文档中隐藏该命令
  })
  .alias("i");

// arguments 可以为最顶层命令指定命令参数。如下例：表示配置一个必选命令 username 和一个可选命令 password。
// arguments 指定的命令参数是泛指，只要不是 command 和 addCommand 注册的命令都会被捕获到。
// zjw-cli-test abc => username 就是 abc
// 可以向.description()方法传递第二个参数，从而在帮助中展示命令参数的信息。该参数是一个包含了 “命令参数名称：命令参数描述” 键值对的对象。
program
  .arguments("<username> [password]")
  .description("test command", {
    username: "user to login",
    password: "password for user, if required",
  })
  .action((username, password) => {
    console.log(username, password);
  });

/**
 * @description 自定义 help 信息
 * 方法一： 步骤一：重写 helpInformation，返回值是啥 help 信息就是啥；。
 * 方法二： 步骤一：重写 helpInformation，返回值空字符串； 步骤二： 调用 on 方法监听 --help，在回调函数中输出 help 信息。
 * 方法三： 步骤一：重写 helpInformation，返回值空字符串； 步骤二： 调用 addHelpText 方法
 *
 * 如果你不想清除内建的帮助信息，
 * 方法一：不重新 helpInformation，只监听 --help；
 * 方法二： 调用 addHelpText 方法（建议这种）
 */

program.helpInformation = function () {
  return "output your help information\n";
};

/**
 * @description 实现 debug 模式
 * 方法一： 调用 on 方法监听 --debug
 * 方法二： 调用 on 方法监听 option:debug
 */
program.on("option:debug", () => {
  console.log("debug: ", program.debug);
  process.env.LOG_LEVEL = "verbose";
});

/**
 * @description 监听未知的命令
 */
program.on("command:*", (obj) => {
  console.log(obj);
  console.error("未知的命令：", obj[0]);
  // 获取所有已注册的命令
  const availableCommands = program.commands.map((cmd) => cmd.name());
  console.log("可用命令：", availableCommands.join(", "));
});

// 解析参数
program.parse(process.argv);
```

### 涉及技术点

#### 核心库

- import-local
- commander

#### 工具库

- npmlog
- fs-extra
- semver
- colors
- user-home
- dotenv
- root-check
